You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
260 lines
7.6 KiB
260 lines
7.6 KiB
<script setup lang="ts">
|
|
import { unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
|
|
import { buildPublicCanonicalUrl } from '../../../utils/public-canonical-url'
|
|
import {
|
|
formatPublishedDateOnly,
|
|
occurredOnToIsoAttr,
|
|
} from '../../../utils/timeline-datetime'
|
|
|
|
definePageMeta({
|
|
layout: 'public',
|
|
})
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const runtimeConfig = useRuntimeConfig()
|
|
const slug = computed(() => route.params.publicSlug as string)
|
|
|
|
function parsePage(raw: unknown): number {
|
|
const n =
|
|
typeof raw === 'string'
|
|
? Number.parseInt(raw, 10)
|
|
: typeof raw === 'number'
|
|
? raw
|
|
: Number.NaN
|
|
if (!Number.isFinite(n) || n < 1) {
|
|
return 1
|
|
}
|
|
return Math.floor(n)
|
|
}
|
|
|
|
const page = ref(parsePage(route.query.page))
|
|
|
|
watch(
|
|
() => [route.params.publicSlug, route.query.page] as const,
|
|
() => {
|
|
page.value = parsePage(route.query.page)
|
|
},
|
|
)
|
|
|
|
type PublicPostListItem = {
|
|
title: string
|
|
excerpt: string
|
|
slug: string
|
|
publishedAt: Date | null
|
|
coverUrl?: string | null
|
|
tags?: string[]
|
|
}
|
|
type TagMode = 'or' | 'and'
|
|
|
|
type Payload = {
|
|
items: PublicPostListItem[]
|
|
total: number
|
|
page: number
|
|
pageSize: number
|
|
availableTags?: string[]
|
|
}
|
|
|
|
const selectedTags = ref<string[]>(
|
|
typeof route.query.tags === 'string'
|
|
? route.query.tags.split(',').map(x => x.trim()).filter(Boolean)
|
|
: [],
|
|
)
|
|
const tagMode = ref<TagMode>(route.query.tagMode === 'and' ? 'and' : 'or')
|
|
const tagModeItems = [
|
|
{ label: '任一命中 (OR)', value: 'or' },
|
|
{ label: '全部命中 (AND)', value: 'and' },
|
|
]
|
|
|
|
function onPageChange(p: number) {
|
|
page.value = p
|
|
const query: Record<string, string | string[] | undefined> = { ...route.query }
|
|
if (p > 1) {
|
|
query.page = String(p)
|
|
}
|
|
else {
|
|
delete query.page
|
|
}
|
|
void router.replace({ query })
|
|
}
|
|
|
|
const { data, pending, error } = await useAsyncData(
|
|
() => `public-posts-${slug.value}-${page.value}-${selectedTags.value.join(',')}-${tagMode.value}`,
|
|
async () => {
|
|
const base = `/api/public/profile/${encodeURIComponent(slug.value)}/posts`
|
|
const query = new URLSearchParams()
|
|
if (page.value > 1) {
|
|
query.set('page', String(page.value))
|
|
}
|
|
if (selectedTags.value.length) {
|
|
query.set('tags', selectedTags.value.join(','))
|
|
}
|
|
query.set('tagMode', tagMode.value)
|
|
const url = query.toString() ? `${base}?${query.toString()}` : base
|
|
const res = await $fetch<ApiResponse<Payload>>(url)
|
|
return unwrapApiBody(res)
|
|
},
|
|
{ watch: [slug, page, selectedTags, tagMode] },
|
|
)
|
|
|
|
const availableTags = computed(() => {
|
|
const byApi = data.value?.availableTags ?? []
|
|
const byItems = (data.value?.items ?? [])
|
|
.flatMap(item => item.tags ?? [])
|
|
.filter(Boolean)
|
|
return [...new Set([...byApi, ...byItems])]
|
|
})
|
|
|
|
watch(
|
|
() => [route.query.tags, route.query.tagMode] as const,
|
|
() => {
|
|
selectedTags.value =
|
|
typeof route.query.tags === 'string'
|
|
? route.query.tags.split(',').map(x => x.trim()).filter(Boolean)
|
|
: []
|
|
tagMode.value = route.query.tagMode === 'and' ? 'and' : 'or'
|
|
},
|
|
)
|
|
|
|
function updateFilterQuery(nextPage = 1) {
|
|
const query: Record<string, string | string[] | undefined> = { ...route.query }
|
|
if (selectedTags.value.length) {
|
|
query.tags = selectedTags.value.join(',')
|
|
}
|
|
else {
|
|
delete query.tags
|
|
}
|
|
query.tagMode = tagMode.value
|
|
if (nextPage > 1) {
|
|
query.page = String(nextPage)
|
|
}
|
|
else {
|
|
delete query.page
|
|
}
|
|
void router.replace({ query })
|
|
}
|
|
|
|
const canonicalUrl = computed(() =>
|
|
buildPublicCanonicalUrl(runtimeConfig.public.siteUrl, route.fullPath),
|
|
)
|
|
|
|
useHead(() => ({
|
|
link: canonicalUrl.value
|
|
? [{ rel: 'canonical', href: canonicalUrl.value }]
|
|
: [],
|
|
}))
|
|
|
|
usePageTitle(() => {
|
|
const parts: string[] = []
|
|
if (page.value > 1) {
|
|
parts.push(`第 ${page.value} 页`)
|
|
}
|
|
parts.push('文章', `@${slug.value}`)
|
|
return parts
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UContainer class="py-8 lg:py-10 max-w-6xl">
|
|
<div v-if="pending && !data" class="text-muted py-10">
|
|
加载中…
|
|
</div>
|
|
<UAlert
|
|
v-else-if="error && !data"
|
|
color="error"
|
|
title="无法加载文章列表"
|
|
class="my-6"
|
|
/>
|
|
<template v-else-if="data">
|
|
<UCard class="mb-4" :ui="{ body: 'p-4 space-y-3' }">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<div class="text-sm text-muted">
|
|
标签筛选
|
|
</div>
|
|
<UButton
|
|
size="xs"
|
|
color="neutral"
|
|
variant="ghost"
|
|
@click="selectedTags = []; tagMode = 'or'; updateFilterQuery(1)"
|
|
>
|
|
清空
|
|
</UButton>
|
|
</div>
|
|
<PostTagsInput
|
|
:model-value="selectedTags"
|
|
:suggestions="availableTags"
|
|
placeholder="输入标签回车筛选"
|
|
@update:model-value="(v) => { selectedTags = v; updateFilterQuery(1) }"
|
|
/>
|
|
<USelect
|
|
v-model="tagMode"
|
|
:items="tagModeItems"
|
|
class="w-full sm:w-56"
|
|
@update:model-value="() => updateFilterQuery(1)"
|
|
/>
|
|
</UCard>
|
|
<UEmpty
|
|
v-if="data.total === 0"
|
|
:title="selectedTags.length ? '没有匹配结果' : '暂无公开文章'"
|
|
:description="selectedTags.length ? '请尝试调整标签筛选条件。' : '站主尚未发布任何公开文章。'"
|
|
/>
|
|
<template v-else>
|
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted mb-4">
|
|
文章
|
|
</h2>
|
|
<ul class="border-t border-default">
|
|
<li
|
|
v-for="p in data.items"
|
|
:key="p.slug"
|
|
class="border-b border-default last:border-b-0"
|
|
>
|
|
<NuxtLink
|
|
:to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`"
|
|
class="group flex flex-col gap-4 py-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-default rounded-lg sm:flex-row sm:gap-5"
|
|
>
|
|
<div
|
|
v-if="p.coverUrl"
|
|
class="w-full shrink-0 overflow-hidden rounded-xl border border-default sm:w-40"
|
|
>
|
|
<img
|
|
:src="p.coverUrl"
|
|
alt=""
|
|
class="aspect-[16/9] w-full object-cover sm:aspect-[4/3] sm:min-h-[7rem] sm:h-full"
|
|
>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<time
|
|
v-if="p.publishedAt"
|
|
class="block text-xs font-medium tabular-nums text-muted"
|
|
:datetime="occurredOnToIsoAttr(p.publishedAt)"
|
|
>{{ formatPublishedDateOnly(p.publishedAt) }}</time>
|
|
<div class="mt-1 text-xl font-semibold text-pretty text-highlighted leading-snug group-hover:text-primary transition-colors">
|
|
{{ p.title }}
|
|
</div>
|
|
<PostTagsPostTagBadges v-if="p.tags?.length" :tags="p.tags" :max="5" class="mt-2" />
|
|
<p
|
|
v-if="p.excerpt"
|
|
class="mt-3 text-sm text-muted text-pretty leading-relaxed"
|
|
>
|
|
{{ p.excerpt }}
|
|
</p>
|
|
<p class="mt-2 text-sm font-medium text-primary group-hover:underline">
|
|
查看全文
|
|
</p>
|
|
</div>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
<div v-if="data.total > 10" class="flex justify-end mt-6">
|
|
<UPagination
|
|
:page="page"
|
|
:total="data.total"
|
|
items-per-page="10"
|
|
size="sm"
|
|
@update:page="onPageChange"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</UContainer>
|
|
</template>
|
|
|